Skip to content

fix(hooks): a linked worktree is one whose git-dir differs from its git-common-dir - #7749

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-7259-structural-linked-worktree-test
Sep 7, 2026
Merged

fix(hooks): a linked worktree is one whose git-dir differs from its git-common-dir#7749
os-zhuang merged 3 commits into
mainfrom
claude/issue-7259-structural-linked-worktree-test

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #7259

Sibling PR (objectstack, same defect, same lines, one flight): objectstack-ai/objectstack#15924
Sibling card: objectstack-ai/objectstack#11809

What was wrong

Both worktree-first guards decided "am I in a linked worktree?" by substring-matching the git-dir path:

case "$gitdir" in
  */worktrees/*) exit 0 ;;   # any path containing `worktrees`, not a linked worktree
esac

That is a test for the characters worktrees appearing anywhere in a path. A primary checkout that merely lives under a directory named worktrees matched it, and both guards allowed edits into a shared primary checkout — the exact failure worktree-first exists to stop.

It was also depth-dependent, which is what made it reachable in practice. git rev-parse --git-dir prints a RELATIVE .git at a repo toplevel and an ABSOLUTE path from any subdirectory, and the guards hand git the edited file's nearest EXISTING ancestor. Measured on the fixture, unchanged from the card:

target ancestor handed to git verdict before
ODD/README.md repo toplevel block — correct only by accident
ODD/brand/new/f.ts walks up to toplevel block — ditto
ODD/pkg/x.ts a subdirectory allow — WRONG
ODD/pkg/brand/new/f.ts a subdirectory allow — WRONG

In a real repo almost every edit is to a file in a subdirectory that already exists. Note the fixture path is not exotic: an operator who keeps trees under a worktrees/ parent directory gets a silently unguarded primary checkout.

The fix — structural, not a sharper pattern

A linked worktree's git-dir (.git/worktrees/NAME) differs from its git-common-dir (.git). A primary checkout has the two equal, and so does a submodule (.git/modules/NAME for both) — which is why neither needs a special case. This holds regardless of how the path is spelled.

canon_dir() { ( cd "$1" 2>/dev/null && pwd -P ) || printf '%s' "$1"; }

gitdir="$(git -C "$d" rev-parse --absolute-git-dir 2>/dev/null)" || exit 0
commondir="$(git -C "$d" rev-parse --git-common-dir 2>/dev/null)" || exit 0
case "$commondir" in /*) ;; *) commondir="$d/$commondir" ;; esac
[ "$(canon_dir "$gitdir")" != "$(canon_dir "$commondir")" ] && exit 0

Two details are load-bearing, and both were measured on this box (git 2.43.0) rather than assumed:

  1. --git-common-dir prints RELATIVE to the directory queried.git at a toplevel, ../.git from a subdirectory. It must be resolved against that directory before the comparison. Ablating only that one line turns 47 matrix cases from block to allow: compared raw it never equals the absolute git-dir, so the guard fails open at every depth.
  2. --absolute-git-dir alone is NOT a fix, exactly as the sibling card's second comment warns. It removes the toplevel/subdirectory asymmetry by making the guard fail open everywhere instead of somewhere. Measurement (1) is the direct evidence for that.

Both sides go through one canonicalisation helper, so symlinked temp dirs and git's relative printing cannot make two spellings of the same directory look different.

A failed rev-parse keeps today's behaviour — not being inside a repo is not this hook's business.

Red / green

The self-test edits landed first, so the flip is shown in both directions. The old hooks were taken from HEAD into a scratch directory; the checked-in tree was never mutated.

New matrices against the OLD hooks:

guard-main-checkout.selftest.sh        114 passed, 2 failed
  FAIL want=block got=allow  $ODD/pkg/x.ts
  FAIL want=block got=allow  $ODD/pkg/brand/new/f.ts
guard-main-checkout-bash.selftest.sh   124 passed, 2 failed
  FAIL want=block got=allow  sed -i s/a/b/ $ODD/pkg/x.ts
  FAIL want=block got=allow  echo x > $ODD/pkg/x.ts

Only the subdirectory cases redden — the toplevel ones passed against the old hook too, because they blocked by accident. That is the sibling card's sharpened trigger condition reproducing exactly.

New matrices against the NEW hooks, and every other hook matrix in this repo:

guard-main-checkout-bash.selftest.sh   126 passed, 0 failed   exit=0
guard-main-checkout.selftest.sh        116 passed, 0 failed   exit=0
guard-shared-stash.selftest.sh          48 passed, 0 failed   exit=0
guard-tree-enum.selftest.sh             36 passed, 0 failed   exit=0

The Bash matrix goes 121 to 126 (the worktrees-segment fixture it never had). The Edit/Write matrix stays at 116 — four cases flipped rather than added. Hook Self-Tests discovers all four by find, so nothing here needed a workflow edit.

Non-vacuity. The recipe printed in the matrix footer was re-aimed at the line that now exists, and then run, so it is not a dead mutation: deleting the linked-worktree escape reddens 29 cases. The deletion was confirmed on disk by a before/after grep count (1 to 0) before the reading was trusted.

Self-test changes

  • The KNOWN HOLE banner and its "record of today's behaviour" prose are gone. The four worktrees-segment cases stay, re-homed under a section titled for the primary checkout they describe, and all four now expect block.
  • The positive direction is named where it was already pinned: $WT is a real linked worktree made with git worktree add, at a path carrying no worktrees segment, allowed at both its toplevel ($WT/README.md) and in a subdirectory ($WT/pkg/x.ts). Both depths matter because git answers them differently. No new case was needed — a comment now says why those rows are load-bearing.
  • The Bash matrix gains the ODD fixture and five cases: three blocks into it (subdirectory via sed -i, subdirectory via redirection, toplevel) plus the primary-checkout control and a linked-worktree control beside them.
  • Submodules keep their existing pins and stay green with no special case, because git-dir equals git-common-dir there. Verified directly on a real submodule fixture.
  • Two stale sentences in this file's PORTED header are corrected rather than left to rot: it claimed "the only edit below the header is the one remaining KNOWN HOLE section" (there is no longer such a section) and measured the cross-repo residual at 9 diff lines (now 10, see below).

Cross-repo alignment

Both repos move together in one flight, and the port stays verbatim. After the change, measured with diff -u:

  • guard-main-checkout.sh10 changed diff lines, all inside comment blocks (the pre-existing worktree-recipe block, plus the one line carrying each repo's own card number). Stripping comments and blank lines, the 41 code lines are identical.
  • guard-main-checkout-bash.sh — 82 changed diff lines: 76 comments, and 6 lines of message prose inside the blocked-message heredoc (each repo's own issue reference and its reflow). Stripping comments, blank lines and heredoc bodies, the 342 code lines are identical.

So the residual is comment-and-message-prose only; no executable line differs between the two repos in either hook — which is what this matrix's own header demands.

Gates

Derived from this repo's AGENTS.md, package.json and .github/workflows/ for a .claude/hooks/** change. All at HEAD 2248bba, exit codes captured before any pipe:

command exit
node scripts/check-changeset-presence.mjs 0
pnpm check:control-bytes 0
pnpm check:shell-escape-residue 0
pnpm check:governed-queue-guard (self-test) 0
pnpm lint (whole repo, turbo run lint, 47/47 tasks) 0
all four .claude/hooks/*.selftest.sh (what Hook Self-Tests runs) 0
node scripts/check-governed-queue-guard.mjs --test on the four paths 3 — GOVERNED

Changeset. This repo has no skip-changeset label and none was invented or applied. The authority is scripts/check-changeset-presence.mjs, and its verdict line on this diff is:

Compared the working tree with 565f2b6aa (merge-base with origin/main): 4 file(s) changed,
0 of them published source of a package the release covers, 0 of them a manifest whose
published contract moved, 0 under a package changesets ignores, 0 changeset(s) added.
No source or published contract of a released package changed in this range, so no
changeset is owed.

So no .changeset/*.md is added — not by exemption, but because nothing guarded changed. Whole-repo lint is reported as a full run rather than a narrowing; for the record its scan surface is **/*.{ts,tsx}, and this diff contains no TypeScript, so the 2883 warnings it reports are pre-existing and untouched.

Maintainer notes

This PR is governed surface (.claude/**) and is parked as a draft: no seat flips it ready, enqueues it, or arms auto-merge. Attribution for this change: authored in Claude Code session session_019RfFHiRCSs3JXLK4cwcfox.

维护者速读(草稿)

改了什么 —— 两个 worktree-first 守卫判断"我是不是在 linked worktree 里"的方式换掉了。原来靠路径里有没有 worktrees 这几个字符,现在问 git 一个结构性问题:git-dir 和 git-common-dir 是不是同一个目录,不同才是 linked worktree。四个文件,两个钩子加它们各自的自测矩阵;objectstack 同步落一份,可执行行逐字节相同。

为什么改 —— 这个守卫存在的唯一理由,就是拦住"往共享主 checkout 里写"。而它恰好在最常见的情形下失灵:只要你的仓库放在一个叫 worktrees 的目录底下(比如 ~/worktrees/objectui 这种再普通不过的布局),编辑任何子目录里的文件都会被放行 —— 而真实开发里几乎每一次编辑都是子目录里的已有文件。失灵时没有任何报错,agent 的改动就这么写进共享树,下一个 agent 切 HEAD 时静默清掉。这不是理论风险,是守卫在它最该起作用的那一类输入上直接失效。

风险与代价(含回滚) —— 改动面很小:两个钩子里各一段判定,没有新依赖,git rev-parse --git-common-dir 是老接口(本机实测 git 2.43.0)。收紧方向是"以前放行的现在拦住",所以理论上的代价是误拦 —— 但本仓四个矩阵共 326 个用例全绿(加 objectstack 那边共 379),含子模块、真 linked worktree、非仓库目录三类正向场景,没有一条正常路径被误伤。真正的坑我们提前量过并写进了代码注释:--git-common-dir 打印的是相对路径,不先解析就比较会让守卫在所有深度上失效(实测 47 条用例翻车),所以卡片上"光换 --absolute-git-dir 就行"的说法是错的,这版没有采纳。回滚成本接近零:revert 这一个 commit 即可,守卫回到今天的行为,没有数据迁移、没有配置、没有下游消费者。本仓不欠 changeset —— 不是走豁免,是 check-changeset-presence 判定这次没动任何发版包的源码。

席位意见 ——

你要做的 —— 这是 governed surface(.claude/**),按规矩只能你本人合。请看一眼两个仓的 PR(这一个和 objectstack 的 https://github.com/objectstack-ai/objectstack/pull/15924),确认判定换法你认可,然后手动合并;两个仓要一起合,否则两边守卫会短暂不一致。席位不会翻 ready、不会入队、不会开自动合并。


🤖 Generated with Claude Code

Generated by Claude Code


Generated by Claude Code

…it-common-dir

Both worktree-first guards decided "am I in a linked worktree?" by substring-matching
the git-dir path against `*/worktrees/*`. That is a test for the characters
`worktrees` appearing anywhere in a path, not a test for a linked worktree: a PRIMARY
checkout that merely lives under a directory named `worktrees` matched it, and both
guards allowed edits into a shared primary checkout — the exact failure worktree-first
exists to stop (see #7259).

The verdict was also depth-dependent, which is what made it reachable in practice.
`git rev-parse --git-dir` prints a RELATIVE `.git` at a repo toplevel and an ABSOLUTE
path from any subdirectory, and the guards hand git the edited file's nearest EXISTING
ancestor. So the same unguarded checkout blocked for a path resolving to the toplevel
and allowed for anything resolving to a subdirectory — and in a real repo almost every
edit is to a file in a subdirectory that already exists.

Replaced with the structural test: a linked worktree's git-dir (.git/worktrees/NAME)
differs from its git-common-dir (.git); a primary checkout has the two equal, and so
does a submodule (.git/modules/NAME for both), so neither needs a special case.

Two details are load-bearing and both are measured, not assumed:

  * `--git-common-dir` prints RELATIVE to the directory queried (`.git` at a toplevel,
    `../.git` from a subdirectory), so it must be resolved against that directory
    before the comparison. Compared raw it never equals the absolute git-dir and the
    guard fails open at EVERY depth — ablating just that line turns 47 matrix cases
    from block to allow.
  * `--absolute-git-dir` alone is NOT a fix. It removes the toplevel/subdirectory
    asymmetry by making the guard fail open everywhere instead of somewhere.

Both sides are canonicalised through one helper so symlinked temp dirs and git's
relative printing cannot make two spellings of the same directory look different.

Self-tests: the four `worktrees`-segment cases are re-homed out of the KNOWN HOLE
section and all four now expect `block`; the Bash matrix gains that fixture and five
cases it never had. The non-vacuity recipe is re-aimed at the line that now exists,
so it is not a dead mutation.

The executable lines stay byte-identical to the sibling repo's copies of both hooks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox

os-steve commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

ACCEPT — lands #7259 as ruled, one flight with objectstack PR #15924 (#11809). Governed (.claude/**), so this PR stays draft at the governed terminal: both approvers requested; needs-user-decision goes on the PR with the final 「维护者速读」 posted beneath this verdict. The two repos must merge together — the executable lines are identical and the guards' whole value is that they agree.

What the seat verified, at head 2248bbad82, in its own compare worktree

  • Four files, +89/−40: both guards replace the */worktrees/* substring case with the structural test (--absolute-git-dir--git-common-dir, the relative common-dir resolved against the queried directory, one canon_dir helper); a failed rev-parse keeps today's behaviour.
  • Direct reproduction by the seat on a fresh fixture (TMP/worktrees/oddrepo, a PRIMARY checkout): the new hook answers ODD/pkg/x.ts with rc 2 (block), where the substring test allowed it.
  • All four .claude/hooks/*.selftest.sh green on the head: 126 · 116 · 48 · 36 passed, 0 failed; the Bash matrix gained the ODD fixture (121 → 126); the Edit/Write matrix's four ODD cases now all expect block; KNOWN HOLE residue 0; the PORTED header's two stale sentences corrected.
  • Cross-repo parity, seat's own read: comment-stripped guard-main-checkout.sh is line-identical between this head and objectstack PR #15924's head.
  • This repo has no skip-changeset label and none was invented; check-changeset-presence reads no changeset owed (no published source changed). Body line 1 Fixes #7259. CI, seat's read 15:1xZ: 30 check runs, 27 success, 3 skipped, none failing, none pending (Hook Self-Tests and Governed Surface Queue Guard among the green).

Reconciliation: the 13:59Z Claim: comment's branch spelling and its "first pair only" scope for this repo were the seat's text, superseded by the dispatch brief the dev correctly followed; the seat edits its own claim comment to what landed.

Implemented-by: os-dev executor, flight objectui #7259 + objectstack #11809, branches claude/issue-7259-structural-linked-worktree-test / claude/issue-11809-structural-linked-worktree-test
Reviewed-by: pm-dispatch skills seat, https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox


Generated by Claude Code

@claude
claude Bot requested review from hotlong and os-zhuang September 5, 2026 15:07

os-steve commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

维护者速读

改了什么:两个「禁止改共享主检出」的守卫钩子(Edit/Write 那个和 Bash 那个)判断「当前是不是 linked worktree」的方法换掉:原来看路径里有没有 worktrees 这几个字,现在问 git 的结构事实(git-dir 与 git-common-dir 是否不同,不同才是 linked worktree)。四个文件:两个钩子 + 两个自测矩阵;objectstack PR #15924 同步落一份,可执行行逐行相同。

为什么改:守卫的唯一职责是拦住往共享主检出里写。原判据在最常见的布局下失效:仓库放在名为 worktrees 的目录下(如 ~/worktrees/objectui),改任何子目录里的已有文件都被放行,且无任何报错;顶层文件反而被拦,让人误以为守卫在工作。席位在干净夹具上直接复现:新钩子拦截(rc 2),旧判据放行。

风险与代价(含回滚):方向是收紧(以前放行的现在拦),理论代价是误拦;本仓四个矩阵 326 用例全绿(两仓合计 379),含子模块、真 linked worktree、非仓库目录三类正向场景。关键坑已实测并写进注释:--git-common-dir 打印相对路径,不先解析会让守卫在所有深度失效(实测 47 用例翻车),所以「换 --absolute-git-dir 即可」的说法是错的,未采纳。无新依赖。本仓不欠 changeset(check-changeset-presence 判定未动任何发版包源码,不是走豁免)。回滚 = revert 一个 commit。两仓必须一起合。

席位意见:建议批准,两仓同批。四轴:业务——守卫在最常见布局下失效是实测的;长远——用 git 的结构判据替代路径猜测;防 AI 错——这正是防 agent 误写共享树的那道门;创业阶段——四文件、无新依赖、不扩面。

你要做的:两仓一起批准并人工合并(本 PR 与 objectstack #15924)。一字:是/否。


Generated by Claude Code

os-steve commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Status for the approvers: the objectstack half of this flight, PR #15924, MERGED at 16:03:37Z (924f0fed1). Until this PR lands, the two repos' worktree-first guards read a linked worktree by different tests (structural there, substring here), which is the divergence the pair was ruled to avoid. Nothing else changed on this head (2248bbad82; 30 checks green); the seat's ACCEPT and 速读 above stand.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 7, 2026 03:25
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 7, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 7, 2026
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 7, 2026

Copy link
Copy Markdown
Contributor

Ruling recorded — approved; the maintainer has queued it (director seat, decision batch #72, 2026-09-07)

Maintainer reply, verbatim: 「7749 已加入队列,其他同意」.

The maintainer approved this PR and added it to the merge queue themselves (timeline: added_to_merge_queue by os-zhuang at 2026-09-07T06:09:06Z). .claude/hooks/ is a governed surface, so the merge is the maintainer's own; the PM seats do not touch it.

Recorded for the ledger:

  • A previous queue entry (2026-09-07T03:25:35Z) was removed by the merge-queue bot at 03:34:17Z. If the queue rejects it again, that is the maintainer's to re-run or to re-price — this ruling stands either way.
  • The sibling objectstack PR #15924 carries the same defect and the same fix; it is on the same governed path and likewise awaits the maintainer's own merge.

needs-user-decisionpm:queue.


Generated by Claude Code

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 7, 2026

Copy link
Copy Markdown
Contributor

Why the merge queue evicted this PR twice — a real red, and it needs a patch round before the maintainer re-queues (director seat, 2026-09-07 07:4x UTC)

Read from the merge-group run of 06:09Z (gh-readonly-queue/main/pr-7749-…): the PR-side checks are all green on 2248bbad82, but two queue jobs fail on the same gate —

  • Lint → step Verify the ported objectstack tooling still matches its pin (node scripts/check-upstream-port-parity.mjs), and
  • Test (shard 1/4)scripts/__tests__/upstream-port-parity-wiring.test.ts "and the tree itself is at parity right now", which shells out to the same gate.

Verdict, verbatim:

✗ .claude/hooks/guard-main-checkout.selftest.sh: DRIFTED from the pinned upstream copy.
    divergence `known-hole-notebook-section-removed`: expected its ported text exactly once, found 0
    divergence `hole-crossref-repointed`: … found 0
    divergence `hole-heading-repointed`: … found 0
    divergence `notebook-verdict-by-its-own-path`: … found 0
    divergence `ported-from-provenance-header`: … found 0
    Upstream is objectstack-ai/objectstack; the pin currently names bf10debd587f6ba891be9eadc2b76c91e15bd82b.
✗ check-upstream-port-parity: 1 of 3 ported file(s) drifted. ⛔ Do not edit the pinned digest by hand to clear this

Mechanism. guard-main-checkout.selftest.sh is a ported copy of objectstack's file, pinned to objectstack bf10debd5 with five declared divergence regions — several of them describing the very KNOWN HOLE block this PR deletes. The queue build merges main (where that gate now runs) with this branch, the declared regions no longer exist, and the gate reads that as drift. It is not flakiness and a re-queue will fail the same way.

Patch round (this PR's lane, not the maintainer): re-sync the pin to the objectstack commit that landed the sibling fix (#15924 merged as 924f0fed1 on 2026-09-05) using the gate's own procedure — --resync <upstream file> --ref <sha> — and re-declare the divergences that still exist against that upstream; ⛔ never hand-edit the digest. Then re-run the parity gate and the wiring test locally, push, and ask the maintainer to re-queue. .claude/hooks/ stays governed: draft-for-human-merge posture unchanged; the maintainer's approval and ruling (batch #72) stand.


Generated by Claude Code

…upstream

The merge queue failed twice on `check-upstream-port-parity` while PR-side CI was
green: `.claude/hooks/guard-main-checkout.selftest.sh` is a PINNED port of
objectstack's copy, and deleting the KNOWN HOLE block that five declared
divergences describe left each of them matching zero times.

Bumping the pin IS the re-sync, so this runs the gate's own procedure against
objectstack@70e77ec3b rather than touching a digest by hand. Upstream has since
landed both halves of this work itself — the structural git-dir vs git-common-dir
section (objectstack#15924) and the routed-path-key table this branch already
carried — so the declared divergence set collapses from 14 entries to 2:

  ported-from-provenance-header          the objectui-only provenance paragraph
  worktrees-section-crossref-repointed   #11809 -> #7259, this repo's own card

Everything the other twelve described is now upstream text, which is the
direction the pin's header says the set is meant to move.

The re-sync also brings in objectstack#15987, which this branch was behind: the
escape hatch now names the environment it actually works in instead of printing
a `VAR=1` command prefix that cannot reach a hook. That section asserts the
hook's refusal text, so `guard-main-checkout.sh` carries the same wording at
both of its message sites — otherwise the ported rows would fail. With that in,
the two repos' hooks differ by 10 diff lines, all inside comment blocks, and no
executable line differs, exactly as the provenance header claims.

guard-main-checkout.selftest.sh: 116 -> 120 passed, 0 failed (the four new
#15987 rows). The other three hook self-tests are unmoved at 126 / 48 / 36.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TezFG8ZMrNH6n5VTNpPpdH

Copy link
Copy Markdown
Contributor

Patch round: the merge-queue gate is green

Pushed head: 92af28038f358d45c30603d5daa2331b9e565669 (92af2803), no force-push. Two commits added on top of 2248bbad: a merge of origin/main (so the gate I ran is the one the queue runs), then the fix.

Red first, on the merged head, before anything changed

node scripts/check-upstream-port-parity.mjs — exit 1, reproducing the queue verdict exactly:

✗ .claude/hooks/guard-main-checkout.selftest.sh: DRIFTED from the pinned upstream copy.
    divergence `known-hole-notebook-section-removed`: expected its ported text exactly once, found 0
    divergence `hole-crossref-repointed`:            expected its ported text exactly once, found 0
    divergence `hole-heading-repointed`:             expected its ported text exactly once, found 0
    divergence `notebook-verdict-by-its-own-path`:   expected its ported text exactly once, found 0
    divergence `ported-from-provenance-header`:      expected its ported text exactly once, found 0
✗ check-upstream-port-parity: 1 of 3 ported file(s) drifted.

What the re-sync changed

Pin ref bf10debd587f6ba891be9eadc2b76c91e15bd82b70e77ec3b566b92d7c6551637d6bc746fb840bb6, moved only by the gate's own procedure. No digest was hand-edited:

node scripts/check-upstream-port-parity.mjs --resync /tmp/upstream-selftest.sh \
  --ref 70e77ec3b566b92d7c6551637d6bc746fb840bb6 \
  --path .claude/hooks/guard-main-checkout.selftest.sh --rewrite-governed-file

exit 0. --rewrite-governed-file is the flag it demands for a .claude/** path, and it said so by name before writing.

Declared divergences for this file: 14 → 2. Upstream has since absorbed twelve of them, which is the direction the pin's own header says the set is meant to move.

kept why it is still a divergence
ported-from-provenance-header the objectui-only PORTED paragraph; upstream has no reason to record where its file was ported to
worktrees-section-crossref-repointed #11809#7259 — this repo's own card for the same defect

Dissolved, because upstream now carries the same text: routed-key-header-sentence, nbpay-payload-helper, tool-name-selects-path-key-section, routed-tool-missing-key-is-drift-section, nopath-probe-uses-an-unrouted-tool, nojq-notebook-and-tool-name-decoy-rows, wiring-section-pins-the-matcher-pairing, matcher-to-table-pairing-block, notebook-verdict-by-its-own-path, hole-heading-repointed, hole-crossref-repointed, known-hole-notebook-section-removed, mutation-recipe-classes. --resync did not refuse on any anchor.

diff between the re-synced file and upstream at that ref shows those two regions and nothing else, so the header's claim that the port is VERBATIM is now literally true.

Which hunks came from which upstream PR

objectstack#15924 (924f0fed1, the sibling of this PR) — already present on this branch before my round. The structural section and its four block rows were byte-identical to upstream's apart from the card number, so the re-sync carried them straight through. Nothing was ported for #15924; the branch had it right.

objectstack#15987 (c2520416c, "the guards' escape hatch names where it actually works") — this branch was behind, and these lines are new here:

  1. .claude/hooks/guard-main-checkout.selftest.sh — the stderr_of / says / lacks helpers, and the section == the hatch names WHERE it works: this hook's own environment, never a prefix == with its four assertions. Arrived via the re-sync as ordinary upstream text.
  2. .claude/hooks/guard-main-checkout.sh — the escape-hatch wording at both of its message sites (the ordinary refusal and the schema-drift refusal). Deliberate non-task exception: re-run with OS_ALLOW_MAIN_EDITS=1. becomes the four-line sentence naming the environment the hook actually reads.

Point 2 is not optional decoration: the ported rows assert the hook's refusal text (lacks 're-run with', says 'hook itself runs in'), so without it the re-synced matrix would fail. On-disk landing was proven by an occurrence count before/after — the dead remedy 2 → 0, the new wording 0 → 2 — not by the editor's exit code.

That also repairs the provenance header's own measurement. The two repos' guard-main-checkout.sh now differ by 10 diff lines, all inside comment blocks, no executable line differing — re-measured with diff, and exactly what the header sentence claims. The residual is the cross-ref line plus upstream's ⭐ recipe-alignment comment block, which carries objectstack card numbers and is deliberately not ported.

Gate verdicts — exit codes captured before any pipe

All at 92af2803, working tree clean.

command exit verdict line
node scripts/check-upstream-port-parity.mjs 0 ✓ 3 ported file(s) match objectstack-ai/objectstack@70e77ec3b modulo their declared divergences.
node scripts/check-upstream-port-parity.mjs --self-test 0 ✓ … 49 cases pass
pnpm exec vitest run scripts/__tests__/upstream-port-parity-wiring.test.ts (from repo root /home/user/objectui-7749) 0 Test Files 1 passed (1) · Tests 10 passed (10)
node scripts/check-governed-queue-guard.mjs --test on the 3 changed paths 3 ⛔ GOVERNED — 2 of 3 path(s) … .claude/** x2
pnpm check:control-bytes 0 ✅ OK (scanned 6594 tracked text file(s); skipped 85 binary)
node scripts/check-changeset-presence.mjs 0 see below
pnpm lint --concurrency=2 (root, whole repo) 0 Tasks: 47 successful, 47 total — 0 errors, 273 pre-existing warnings

Hook self-tests, all exit 0:

matrix before now
guard-main-checkout.selftest.sh 116 passed, 0 failed 120 passed, 0 failed
guard-main-checkout-bash.selftest.sh 126 126 passed, 0 failed
guard-shared-stash.selftest.sh 48 48 passed, 0 failed
guard-tree-enum.selftest.sh 36 36 passed, 0 failed

The +4 is exactly the #15987 section's four rows. The other three matrices are unmoved.

Changeset — verified with the script, not assumed:

Compared the working tree with fc32921aa (merge-base with origin/main): 5 file(s) changed,
0 of them published source of a package the release covers, 0 of them a manifest whose
published contract moved, 0 under a package changesets ignores, 0 changeset(s) added.
✅  No source or published contract of a released package changed in this range, so no changeset is owed.

Whole-repo lint is a real full run, not a narrowing. For the record all three changed files are outside eslint's population — --format json reports File ignored because no matching configuration was supplied. for each — and no type-aware linting is configured, so this diff cannot move any untouched file's verdict.

Ablation

From the committed state, reverting the pin bump alone (git checkout 92af2803^ -- scripts/upstream-port-pin.json, leaving the re-synced self-test in place):

  • mutation proven on disk before the reading was trusted: ref back to bf10debd58…, divergence count back to 14, on-disk blob b57ffd03… ≠ HEAD blob c2b7b1e1…
  • the gate: exit 1, 1 of 3 ported file(s) drifted, naming the same five divergences in the same order as the queue log
  • restored with git checkout HEAD -- …; disk blob c2b7b1e1… equals the HEAD blob c2b7b1e1… and git diff HEAD is empty; gate back to exit 0

So the pin bump is load-bearing, and the restore is proven by hash rather than by a clean exit code.

Two things for the maintainer, neither fixed here

1. The pin's ref is global, its digests are per-file — so any single-file re-sync makes the other entries' printed provenance false. resync() sets pin.upstream.ref unconditionally while updating only the re-synced entry's digest. The gate now prints, for the two files I did not touch:

✓ scripts/pm/check-half-states.mjs: byte-identical to …@70e77ec3b:… modulo 11 declared divergence(s).
✓ scripts/invoked-as.mjs:           byte-identical to …@70e77ec3b:… modulo 9 declared divergence(s).

Both statements are false as to the ref. Measured:

ported file pinned digest upstream@bf10debd58 upstream@70e77ec3b5
scripts/pm/check-half-states.mjs 449a0aec… 449a0aec… 274bac47…
scripts/invoked-as.mjs 90f72bf4… 90f72bf4… 6d99f65c…

They stay green — the digest comparison is the assertion and their bytes are untouched — but the ref beside them now names a tree their digests were not taken from. I did not resync them, and I could not have made the ref fully honest in this round either way. Probing both with the gate's own rewrite(…, 'forward'):

  • scripts/invoked-as.mjs — all 9 divergences still apply; a mechanical re-sync (138 diff lines).
  • scripts/pm/check-half-states.mjs5 of 11 divergences no longer apply (summary-unread-branch, closed-window-resolver, closed-window-fetch-gate, summary-disabled-beats-floor, closed-window-self-test), and upstream has moved ~8,600 diff lines. That needs those five re-decided by hand: its own card, not a rider on a hooks PR.

Repairing the printed provenance properly means a per-file ref in the pin, which is an edit to scripts/check-upstream-port-parity.mjs — out of bounds for this round by instruction, and reported rather than attempted.

2. The Bash sibling still prints the remedy #15987 removed. guard-main-checkout-bash.sh ends both refusals with Deliberate non-task exception: re-run with OS_ALLOW_MAIN_EDITS=1. — the instruction that cannot work where it is printed. It is not in the pin and its own matrix asserts nothing about the message, so nothing catches it, and the two guards now disagree about their shared hatch. Left alone deliberately: outside this round's scope, and it needs the says/lacks rows ported alongside it to be worth anything.

PR state — untouched, and one thing I did not do

  • The approval survived the push. Two APPROVED reviews from os-zhuang (a GOVERNED_APPROVERS member) are on the PR, left on 2248bbad. Per the queue guard's own message the review counts "on whichever commit it was left (maintainer ruling 2026-09-04)", so the push did not cost it.
  • The PR is currently draft: false. I did not flip it, and I have not flipped it back — that state was set by another actor and reverting it is not mine to do. Flagging it because the body says the PR is parked as a draft, and because .claude/** is governed surface. Nothing was enqueued, no auto-merge armed, no review submitted.
  • pm:queue label untouched; base is main; 5 files changed across 3 commits.

Re-queue at your convenience — the gate that failed twice is green at 92af2803.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Patch round landed on the branch — head 92af28038; for the maintainer: re-queue once CI is green (director seat, 2026-09-07 08:4x UTC)

The dev seat reproduced the queue's red first (same five zero-occurrence divergences), then re-synced the ported self-test with the gate's own --resync … --ref 70e77ec3b --rewrite-governed-file — no digest hand-edited, the gate script untouched. Upstream had absorbed 12 of the 14 declared divergences (14 → 2). objectstack#15924's structural section was already identical here; objectstack#15987's escape-hatch wording and its four self-test rows were new and are now ported into both the hook and its self-test, so the two repos' hooks differ by comment lines only.

Readings at 92af28038 (exit codes captured before any pipe): parity gate exit 0 (3/3 files match modulo declared divergences); gate self-test 49/49; wiring test 10/10 from the repo root; hook self-tests 120 / 126 / 48 / 36 passed, 0 failed (+4 = the #15987 rows); check:control-bytes 0; changeset presence: none owed (measured); root pnpm lint 47/47, 0 errors. Ablation: reverting the pin bump alone reproduces the queue's five-divergence red; restore proven by blob hash.

Two findings the seat could not fold in are filed: objectui#8271 (the Bash guard still names the dead OS_ALLOW_MAIN_EDITS=1 remedy) and objectui#8272 (the pin's one global ref vs per-file digests).

Posture: .claude/hooks/** governed; nothing flipped, queued, armed or approved by a seat. The maintainer's two approvals are on 2248bbad8; CI on the new head is running (18 green, 9 in progress at the time of writing). When it is green, one "merge when ready" click puts it back in the queue.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Correction to the comment above: the two follow-up findings are #8287 (Bash guard's dead OS_ALLOW_MAIN_EDITS=1 remedy) and #8288 (pin's global ref vs per-file digests) — not #8271 / #8272, which I wrote before the cards were created. Director seat error, recorded rather than edited away.


Generated by Claude Code

@os-zhuang
os-zhuang added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit 998d846 Sep 7, 2026
31 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-7259-structural-linked-worktree-test branch September 7, 2026 09:38
baozhoutao pushed a commit that referenced this pull request Sep 7, 2026
…global one

The pin carried ONE global `upstream.ref` beside PER-FILE digests, and
`--resync` set that global field on every run while updating only the
re-synced entry's digest. Re-syncing one file therefore re-labelled the
others with a ref their digests had never been taken from.

Measured on the tree: after #7749 re-synced the hook self-test to
objectstack `70e77ec3b`, the gate printed that ref beside all three files.
The other two carry digests taken at `bf10debd5`, confirmed by hashing the
upstream blobs at both refs:

  scripts/pm/check-half-states.mjs  bf10debd5 449a0aec…  70e77ec3b 274bac47…
  scripts/invoked-as.mjs            bf10debd5 90f72bf4…  70e77ec3b 6d99f65c…

The pinned digests are the `bf10debd5` ones. All three stayed GREEN — the
digest is the assertion and the bytes were untouched — while the provenance
line beside two of them was false.

`ref` is now a required field on each `files[]` entry and the global one is
RETIRED rather than kept as a default: a default would be read as
`entry.ref ?? pin.upstream.ref`, which is the same false label re-spelled as
a feature. `validatePin` refuses a pin that still carries `upstream.ref`.
`upstream.repo` stays global — it is measurably uniform.

The two entries' refs are corrected to `bf10debd5`. That is a correction of
a false label, NOT a re-sync: no digest, no divergence and no ported file's
bytes change (the diff on the pin is one removed global ref and three added
per-file refs, nothing else).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

3 participants